﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using System.Xml;
using System.IO;
using LockCrypt.Core;

namespace LockCrypt.DAL {
    /// <summary>
    /// Uses an Xml Database to store LockCrypt data.
    /// </summary>
    public class XmlProvider : FileBasedDatabase, IDbProvider {
        private readonly IEncryptionService _encryptionService;
        private bool? _isConnected;
        
        /// <summary>
        /// Gets the encryption provider.
        /// </summary>
        /// <value>The encryption provider.</value>
        public IEncryptionService Encryption {
            get { return _encryptionService; }
        }

        /// <summary>
        /// Gets the type of database which this provider can access.
        /// </summary>
        /// <value>The type of database which this provider can access.</value>
        public Constants.DatabaseType DatabaseType {
            get { return Constants.DatabaseType.XML; }
        }

        /// <summary>
        /// Gets a value indicating whether this instance is either connected or can connect.
        /// </summary>
        /// <value>
        /// 	<c>true</c> if this instance is connected or can connect, otherwise <c>false</c>.
        /// </value>
        public bool IsConnected {
            get {
                if(!_isConnected.HasValue) {
                    _isConnected = CheckPassword();
                }
                return _isConnected.Value;
            }
        }

        /// <summary>
        /// Initializes a new <see cref="XmlProvider"/> for a specified file, using the current encryption service and key.
        /// </summary>
        /// <param name="fileName">Name of the database file.</param>
        public XmlProvider(string fileName) 
            :base(fileName) {
            _encryptionService = new NullEncryptionService();
        }

        /// <summary>
        /// Initializes a new <see cref="XmlProvider"/> for a specified file
        /// </summary>
        /// <param name="fileName">Name of the database file.</param>
        /// <param name="encryptionService">The encryption service.</param>
        public XmlProvider(string fileName, IEncryptionService encryptionService)
            : this(fileName) {
            _encryptionService = encryptionService;
        }

        /// <summary>
        /// Gets all items with a specified parent.
        /// </summary>
        /// <param name="parentId">The parent ID.</param>
        /// <returns></returns>
        public KeyValuePair<IEnumerable<IGroup>, IEnumerable<IAccount>> GetItemsByParent(string parentId) {
            XDocument db = ReadDatabaseDocument();
            IEnumerable<XElement> groupElements = db.Element("lockcrypt").Element("groups").Elements("group"),
                                  accountElements = db.Element("lockcrypt").Element("accounts").Elements("account");
            List<IGroup> groups = new List<IGroup>();
            parentId = Convert.ToString(parentId); // make sure it's an empty string rather than null
            foreach(var element in groupElements) {
                try {
                    if(Convert.ToString(element.Element("parentid").Value) == parentId) {
                        groups.Add(GroupBase.FromXElement(element));
                    }
                } catch(Exception ex) {
                    Console.WriteLine("Error #343: " + ex.Message);
                }
            }
            List<IAccount> accounts = new List<IAccount>();
            foreach(var element in accountElements) {
                try {
                    if(Convert.ToString(element.Element("parentid").Value) == parentId) {
                        accounts.Add(AccountBase.FromXElement(element));
                    }
                } catch(Exception ex) {
                    Console.WriteLine("Error #344: " + ex.Message);
                }
            }
            return new KeyValuePair<IEnumerable<IGroup>,IEnumerable<IAccount>>(groups, accounts);
        }

        /// <summary>
        /// Changes the encryption password.
        /// </summary>
        /// <param name="newPassword">The new password.</param>
        public void ChangePassword(string newPassword) {
            var db = ReadDatabase();
            _encryptionService.EncryptionKey = newPassword;
            SaveDatabase(db, null);
        }

        #region Accounts
        /// <summary>
        /// Adds accounts to the default database.
        /// </summary>
        /// <param name="accounts">The accounts to add.</param>
        /// <param name="logger">The delegate to use to log progress (can be null).</param>
        public void AddAccounts(IEnumerable<IAccount> accounts, ILogProvider logger) {
            var db = ReadDatabase();
            // if the ID is the same, it's more than likely the same instance, but from an imported file.
            var newIds = db.Accounts.Select(a => a.ID);
            db.Accounts.AddRange(accounts.Where(a => !newIds.Contains(a.ID)));
            SaveDatabase(db, logger);
        }

        /// <summary>
        /// Edits an account.
        /// </summary>
        /// <param name="account">The account.</param>
        public void EditAccount(IAccount account) {
            var db = ReadDatabase();
            db.Accounts.RemoveAll(a => a.ID == account.ID);
            db.Accounts.Add(account);
            SaveDatabase(db, null);
        }

        /// <summary>
        /// Deletes an account.
        /// </summary>
        /// <param name="accountId">The account ID.</param>
        public void DeleteAccount(string accountId) {
            var db = ReadDatabase();
            db.Accounts.RemoveAll(a => a.ID == accountId);
            SaveDatabase(db, null);
        }

        public List<IAccount> GetAccounts() {
            XDocument db = ReadDatabaseDocument();
            IEnumerable<XElement> elements = db.Element("lockcrypt").Element("accounts").Elements("account");
            List<IAccount> accounts = new List<IAccount>();
            foreach(var element in elements) {
                try {
                    IAccount account = AccountBase.FromXElement(element);
                    accounts.Add(account);
                } catch(Exception ex) {
                    Console.WriteLine("Error loading account: " + ex.Message);
                }
            }
            return accounts;
        }

        /// <summary>
        /// Gets an account by ID.
        /// </summary>
        /// <param name="accountId">The account ID.</param>
        /// <returns>The account with the matching ID, or <c>null</c></returns>
        public IAccount GetAccountByID(string accountId) {
            XDocument db = ReadDatabaseDocument();
            XElement element = db.Element("lockcrypt").Element("accounts").Elements("account").FirstOrDefault(e => e.Attribute("id").Value == accountId);
            IAccount account = null;
            if(element != null) {
                account = AccountBase.FromXElement(element);
                var dummyElement = new XElement("dummy", string.Empty);
                XElement templateElement = (db.Element("lockcrypt").Element("templates") ?? dummyElement).Elements("template").FirstOrDefault(e => e.Attribute("id").Value == account.Template.ID);
                if(templateElement != null)
                    account.Template = TemplateBase.FromXElement(element);
            }
            return account;
        }
        #endregion

        #region Groups
        /// <summary>
        /// Adds account groups to the default database.
        /// </summary>
        /// <param name="groups">The groups to add.</param>
        /// <param name="logger">The delegate to use to log progress (can be null).</param>
        public void AddGroups(IEnumerable<IGroup> groups, ILogProvider logger) {
            var db = ReadDatabase();
            // if the ID is the same, it's more than likely the same instance, but from an imported file.
            var newGroupIds = db.Groups.Select(g=>g.ID);
            db.Groups.AddRange(groups.Where(g=> !newGroupIds.Contains(g.ID)));
            SaveDatabase(db, logger);
        }

        /// <summary>
        /// Edits a group.
        /// </summary>
        /// <param name="group">The group.</param>
        public void EditGroup(IGroup group) {
            var db = ReadDatabase();
            db.Groups.RemoveAll(g => g.ID == group.ID);
            db.Groups.Add(group);
            SaveDatabase(db, null);
        }

        /// <summary>
        /// Deletes a group and all child groups/accounts.
        /// </summary>
        /// <param name="group">The group.</param>
        public void DeleteGroup(IGroup group) {
            var db = ReadDatabase();
            IEnumerable<INestable> nestedGroups = LockCryptBLL.ArrangeGroups(db.Groups, null);
            IGroup groupToDelete = null; // find the group in the heirarchy
            foreach(var nestedGroup in nestedGroups) {
                // search top level groups
                groupToDelete = group.FindGroup(group.ID);
                if(groupToDelete != null)
                    break;
            }
            IEnumerable<IGroup> unavailableGroups = (groupToDelete).Flatten();
            var groupIds = unavailableGroups.Select(g => g.ID);

            db.Groups.RemoveAll(g => groupIds.Contains(g.ID));
            SaveDatabase(db, null);
        }

        /// <summary>
        /// Gets a list of account groups from the current database.
        /// </summary>
        /// <returns>A list of account groups in the database.</returns>
        public List<IGroup> GetGroups() {
            XDocument db = ReadDatabaseDocument();
            IEnumerable<XElement> elements = db.Element("lockcrypt").Element("groups").Elements("group");
            List<IGroup> groups = new List<IGroup>();
            foreach(var element in elements) {
                try {
                    IGroup group = GroupBase.FromXElement(element);
                    groups.Add(group);
                } catch(Exception ex) {
                    Console.WriteLine("Error loading group: " + ex.Message);
                }
            }
            return groups.ToList();
        }

        /// <summary>
        /// Gets a group by ID.
        /// </summary>
        /// <param name="groupId">The group ID.</param>
        /// <returns>The groups with the matching ID, or <c>null</c></returns>
        public IGroup GetGroupByID(string groupId) {
            XDocument db = ReadDatabaseDocument();
            XElement element = db.Element("lockcrypt").Element("groups").Elements("group").FirstOrDefault(e => e.Attribute("id").Value == groupId);
            IGroup group = null;
            if(element != null)
                group = GroupBase.FromXElement(element);
            return group;
        }
        #endregion

        #region Templates
        /// <summary>
        /// Adds templates to the default database.
        /// </summary>
        /// <param name="templates">The templates to add.</param>
        /// <param name="logger">The delegate to use to log progress (can be null).</param>
        /// <returns><c>null</c> on success, a log of errors on failure.</returns>
        public void AddTemplates(IEnumerable<ITemplate> templates, ILogProvider logger) {
            var db = ReadDatabase();
            // if the ID is the same, it's more than likely the same instance, but from an imported file.
            var newIds = db.Templates.Select(t => t.ID);
            db.Templates.AddRange(templates.Where(t => !newIds.Contains(t.ID)));
            SaveDatabase(db, logger);
        }

        /// <summary>
        /// Edits a template.
        /// </summary>
        /// <param name="template">The template.</param>
        public void EditTemplate(ITemplate template) {
            var db = ReadDatabase();
            db.Templates.RemoveAll(t => t.ID == template.ID);
            db.Templates.Add(template);
            SaveDatabase(db, null);
        }

        /// <summary>
        /// Deletes a template.
        /// </summary>
        /// <param name="templateId">The template ID.</param>
        public void DeleteTemplate(string templateId) {
            var db = ReadDatabase();
            db.Templates.RemoveAll(t => t.ID == templateId);
            SaveDatabase(db, null);
        }

        /// <summary>
        /// Gets a list of templates from the current database file.
        /// </summary>
        /// <returns>A list of templates in the database.</returns>
        public List<ITemplate> GetTemplates() {
            XDocument db = ReadDatabaseDocument();
            IEnumerable<XElement> elements = db.Element("lockcrypt").Element("templates").Elements("template");
            List<ITemplate> templates = new List<ITemplate>();
            foreach(var element in elements) {
                try {
                    ITemplate template = TemplateBase.FromXElement(element);
                    templates.Add(template);
                } catch(Exception ex) {
                    Console.WriteLine("Error loading template: " + ex.Message);
                }
            }
            return templates.ToList();
        }

        /// <summary>
        /// Gets matching templates by name.
        /// </summary>
        /// <param name="templateName">Name of the template.</param>
        /// <returns>All templates with a matching name.</returns>
        public List<ITemplate> GetTemplatesByName(string templateName) {
            return GetTemplates().Where(t => t.Name.Equals(templateName, StringComparison.InvariantCultureIgnoreCase)).ToList();
        }

        /// <summary>
        /// Gets a template by ID.
        /// </summary>
        /// <param name="templateId">The template ID.</param>
        /// <returns>The template with the matching ID, or <c>null</c></returns>
        public ITemplate GetTemplateByID(string templateId) {
            XDocument db = ReadDatabaseDocument();
            XElement element = db.Element("lockcrypt").Element("templates").Elements("template").FirstOrDefault(e => e.Attribute("id").Value == templateId);
            ITemplate template = null;
            if(element != null)
                template = TemplateBase.FromXElement(element);
            /*foreach(var element in elements) {
                try {
                    if(element.Element("id").Value == groupId) {
                        group = GroupBase.FromXElement(element);
                    }
                } catch(Exception ex) {
                    Console.WriteLine("Error loading group: " + ex.Message);
                }
            }*/
            return template;
        }
        #endregion

        #region Database Maintenance
        /// <summary>
        /// Checks the password entered is valid.
        /// </summary>
        /// <returns>
        /// 	<c>true</c> if the password is valid, otherwise <c>false</c>.
        /// </returns>
        public bool CheckPassword() {
            try {
                if(string.IsNullOrEmpty(_filename))
                    return false;
                byte[] rawContents = ReadDatabaseBytes();
                //byte[] rawContents = File.ReadAllBytes(_filename);
                string rawXml = string.IsNullOrEmpty(_encryptionService.EncryptionKey) ? Encoding.UTF8.GetString(rawContents, 0, rawContents.Length) : _encryptionService.DecryptString(rawContents);
                XDocument doc = string.IsNullOrEmpty(rawXml) ? CreateDatabaseDocument(new DatabaseContents()) : XDocument.Parse(rawXml.Trim().Trim(Convert.ToChar("\x00")), LoadOptions.None);
                _isConnected = true;
                return true;
            } catch {
                return false;
            }
        }

        /// <summary>
        /// Creates a new database file.
        /// </summary>
        /// <exception cref="System.Exception">Multiple exceptions, check the reference for DirectoryInfo.Create and the FileInfo constructor.</exception>
        public void CreateDatabase() {
            FileInfo info = new FileInfo(_filename);
            if(!info.Directory.Exists) {
                info.Directory.Create();
            }
            SaveDatabase(new DatabaseContents(), null);
        }

        /// <summary>
        /// Clears the database.
        /// </summary>
        public void ClearDatabase() {
            SaveDatabase(new DatabaseContents(), null);
        }

        /// <summary>
        /// Reads the whole contents of the database.
        /// </summary>
        /// <returns>The whole contents of the database.</returns>
        public DatabaseContents ReadDatabase() {
            XDocument doc = ReadDatabaseDocument();
            XElement root = doc.Element("lockcrypt");

            IEnumerable<XElement> accountElements = root.Element("accounts").Elements("account");
            IEnumerable<IAccount> accounts = accountElements.Select(x => AccountBase.FromXElement(x));

            IEnumerable<XElement> templateElements = root.Element("templates").Elements("template");
            IEnumerable<ITemplate> templates = templateElements.Select(x => TemplateBase.FromXElement(x));

            IEnumerable<XElement> groupElements = root.Element("groups").Elements("group");
            IEnumerable<IGroup> groups = groupElements.Select(x => GroupBase.FromXElement(x));

            DatabaseContents contents = new DatabaseContents() {
                Accounts = accounts.ToList(),
                Templates = templates.ToList(),
                Groups = groups.ToList()
            };
            //Console.WriteLine(string.Format("{0} accounts, {1} groups, {2} templates", contents.Accounts.Count, contents.Groups.Count, contents.Templates.Count));
            return contents;
        }

        /// <summary>
        /// Reads a database from the default file.
        /// </summary>
        /// <returns>The current database contents.</returns>
        private XDocument ReadDatabaseDocument() {
            try {
                byte[] rawContents = ReadDatabaseBytes();
                string rawXml = string.IsNullOrEmpty(_encryptionService.EncryptionKey) ? Encoding.UTF8.GetString(rawContents, 0, rawContents.Length) : _encryptionService.DecryptString(rawContents);
                XDocument doc = string.IsNullOrEmpty(rawXml) ? CreateDatabaseDocument(new DatabaseContents()) : XDocument.Parse(rawXml.Trim().Trim((char)0), LoadOptions.None);
                return doc;
            } catch(Exception Exception) {
                throw new Exception(I18nUtils.GetString("Errors", "ErrorLoadingDatabase") + " " + Exception.Message);
            }
        }

        private byte[] ReadDatabaseBytes() {
            byte[] rawContents;
            using(FileStream fs = new FileStream(_filename, FileMode.Open))
            using(BinaryReader reader = new BinaryReader(fs)) {
                rawContents = reader.ReadBytes(Convert.ToInt32(fs.Length));
            }
            return rawContents;
        }

        /// <summary>
        /// Gets an XDocument for a set of contents database.
        /// </summary>
        /// <returns>An XDocument containing <c>contents</c>.</returns>
        private static XDocument CreateDatabaseDocument(DatabaseContents contents) {
            ILogProvider logger = new NullLogger();
            XDocument doc = new XDocument(new XDeclaration("1.0", "UTF-8", "yes"));
            XElement root = new XElement("lockcrypt");
            doc.Add(root);
            XElement meta = new XElement("meta",
                new XElement("generator", "LockCrypt (.NET) " + Constants.GetVersion().ToString(3)),
                new XElement("date", DateTime.Now.ToUniversalTime().ToString("s")));
            root.Add(meta);

            int numItems = 0;
            XElement xAccounts = new XElement("accounts"); // pwlist -> pwentry in v <= 2.31
            root.Add(xAccounts);
            foreach(var account in contents.Accounts) {
                try {
                    xAccounts.Add(account.ToXElement());
                    numItems++;
                } catch(Exception ex) {
                    logger.Log(ex.Message, true);
                }
            }
            logger.Log(string.Format("Read {0} accounts", numItems), StatusMessage.StatusMessageType.Success);

            numItems = 0;
            XElement xTemplates = new XElement("templates"); // pwlist -> type in v <= 2.31
            root.Add(xTemplates);
            foreach(var template in contents.Templates) {
                try {
                    xTemplates.Add(template.ToXElement());
                    numItems++;
                } catch(Exception ex) {
                    logger.Log(ex.Message, true);
                }
            }
            logger.Log(string.Format("Read {0} templates", numItems), StatusMessage.StatusMessageType.Success);

            numItems = 0;
            XElement xGroups = new XElement("groups"); // non-existant in v <= 2.31
            root.Add(xGroups);
            foreach(var group in contents.Groups) {
                try {
                    xGroups.Add(group.ToXElement());
                    numItems++;
                } catch(Exception ex) {
                    logger.Log(ex.Message, true);
                }
            }
            logger.Log(string.Format("Read {0} groups", numItems), StatusMessage.StatusMessageType.Success);

            return doc;
        }

        /// <summary>
        /// Saves the contents of the database to a file.
        /// </summary>
        /// <param name="contents">The database contents.</param>
        /// <param name="logger">The logger to use.</param>
        public void SaveDatabase(DatabaseContents contents, ILogProvider logger) {
            if(logger == null) {
                logger = new NullLogger();
            }
            XDocument dbContents = CreateDatabaseDocument(contents);
            byte[] encryptedDatabase = string.IsNullOrEmpty(_encryptionService.EncryptionKey) ? Encoding.UTF8.GetBytes(dbContents.ToString(SaveOptions.None)) : _encryptionService.EncryptString(dbContents.ToString(SaveOptions.None));
            using(FileStream fs = new FileStream(_filename, FileMode.Create))
            using(BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8)) {
                writer.Write(encryptedDatabase);//, 0, encryptedDatabase.Length);
                writer.Flush();
            }
            //File.WriteAllBytes(_filename, encryptedDatabase);
        }
        #endregion
    }
}
